/*
 * Copyright (C) 2008 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.anderground.app;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.impl.client.DefaultHttpClient;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.AlertDialog.Builder;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;
import android.util.Log;
import android.util.Xml;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;

public class DownloaderActivity extends Activity {

	/**
	 * Checks if data has been downloaded. If so, returns true. If not, starts
	 * an activity to download the data and returns false. If this function
	 * returns false the caller should immediately return from its onCreate
	 * method. The calling activity will later be restarted (using a copy of its
	 * original intent) once the data download completes.
	 * 
	 * @param activity
	 *            The calling activity.
	 * @param customText
	 *            A text string that is displayed in the downloader UI.
	 * @param fileConfigUrl
	 *            The URL of the download configuration URL.
	 * @param configVersion
	 *            The version of the configuration file.
	 * @param dataPath
	 *            The directory on the device where we want to store the data.
	 * @param userAgent
	 *            The user agent string to use when fetching URLs.
	 * @return true if the data has already been downloaded successfully, or
	 *         false if the data needs to be downloaded.
	 */
	public static boolean ensureDownloaded(Activity activity,
			String customText, String fileConfigUrl, String configVersion,
			String dataPath, String userAgent) {
		File dest = new File(dataPath);
		if (dest.exists()) {
			// Check version
			if (versionMatches(dest, configVersion)) {
				Log.i(LOG_TAG, "Versions match, no need to download.");
				return true;
			}
		}
		Intent intent = PreconditionActivityHelper.createPreconditionIntent(
				activity, DownloaderActivity.class);
		intent.putExtra(EXTRA_CUSTOM_TEXT, customText);
		intent.putExtra(EXTRA_FILE_CONFIG_URL, fileConfigUrl);
		intent.putExtra(EXTRA_CONFIG_VERSION, configVersion);
		intent.putExtra(EXTRA_DATA_PATH, dataPath);
		intent.putExtra(EXTRA_USER_AGENT, userAgent);
		PreconditionActivityHelper.startPreconditionActivityAndFinish(activity,
				intent);
		return false;
	}

	/**
	 * Delete a directory and all its descendants.
	 * 
	 * @param directory
	 *            The directory to delete
	 * @return true if the directory was deleted successfully.
	 */
	public static boolean deleteData(String directory) {
		return deleteTree(new File(directory), true);
	}

	private static boolean deleteTree(File base, boolean deleteBase) {
		boolean result = true;
		if (base.isDirectory()) {
			for (File child : base.listFiles()) {
				result &= deleteTree(child, true);
			}
		}
		if (deleteBase) {
			result &= base.delete();
		}
		return result;
	}

	private static boolean versionMatches(File dest, String expectedVersion) {
		Config config = getLocalConfig(dest, LOCAL_CONFIG_FILE);
		if (config != null) {
			return config.version.equals(expectedVersion);
		}
		return false;
	}

	private static Config getLocalConfig(File destPath, String configFilename) {
		File configPath = new File(destPath, configFilename);
		FileInputStream is;
		try {
			is = new FileInputStream(configPath);
		} catch (FileNotFoundException e) {
			return null;
		}
		try {
			Config config = ConfigHandler.parse(is);
			return config;
		} catch (Exception e) {
			Log.e(LOG_TAG, "Unable to read local config file", e);
			return null;
		} finally {
			quietClose(is);
		}
	}

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		Intent intent = getIntent();
		requestWindowFeature(Window.FEATURE_CUSTOM_TITLE);
		setContentView(R.layout.downloader);
		getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE,
				R.layout.downloader_title);
		((TextView) findViewById(R.id.customText)).setText(intent
				.getStringExtra(EXTRA_CUSTOM_TEXT));
		mProgress = (TextView) findViewById(R.id.progress);
		mTimeRemaining = (TextView) findViewById(R.id.time_remaining);
		Button button = (Button) findViewById(R.id.cancel);
		button.setOnClickListener(new Button.OnClickListener() {
			public void onClick(View v) {
				if (mDownloadThread != null) {
					mSuppressErrorMessages = true;
					mDownloadThread.interrupt();
				}
			}
		});
		startDownloadThread();
	}

	private void startDownloadThread() {
		mSuppressErrorMessages = false;
		mProgress.setText("");
		mTimeRemaining.setText("");
		mDownloadThread = new Thread(new Downloader(), "Downloader");
		mDownloadThread.setPriority(Thread.NORM_PRIORITY - 1);
		mDownloadThread.start();
	}

	@Override
	protected void onResume() {
		super.onResume();
	}

	@Override
	protected void onDestroy() {
		super.onDestroy();
		mSuppressErrorMessages = true;
		mDownloadThread.interrupt();
		try {
			mDownloadThread.join();
		} catch (InterruptedException e) {
			// Don't care.
		}
	}

	private void onDownloadSucceeded() {
		Log.i(LOG_TAG, "Download succeeded");
		PreconditionActivityHelper.startOriginalActivityAndFinish(this);
	}

	private void onDownloadFailed(String reason) {
		Log.e(LOG_TAG, "Download stopped: " + reason);
		String shortReason;
		int index = reason.indexOf('\n');
		if (index >= 0) {
			shortReason = reason.substring(0, index);
		} else {
			shortReason = reason;
		}
		AlertDialog alert = new Builder(this).create();
		alert.setTitle(R.string.download_activity_download_stopped);

		if (!mSuppressErrorMessages) {
			alert.setMessage(shortReason);
		}

		alert.setButton(getString(R.string.download_activity_retry),
				new DialogInterface.OnClickListener() {
					public void onClick(DialogInterface dialog, int which) {
						startDownloadThread();
					}

				});
		alert.setButton2(getString(R.string.download_activity_quit),
				new DialogInterface.OnClickListener() {
					public void onClick(DialogInterface dialog, int which) {
						finish();
					}

				});
		try {
			alert.show();
		} catch (WindowManager.BadTokenException e) {
			// Happens when the Back button is used to exit the activity.
			// ignore.
		}
	}

	private void onReportProgress(int progress) {
		mProgress.setText(mPercentFormat.format(progress / 10000.0));
		long now = SystemClock.elapsedRealtime();
		if (mStartTime == 0) {
			mStartTime = now;
		}
		long delta = now - mStartTime;
		String timeRemaining = getString(R.string.download_activity_time_remaining_unknown);
		if ((delta > 3 * MS_PER_SECOND) && (progress > 100)) {
			long totalTime = 10000 * delta / progress;
			long timeLeft = Math.max(0L, totalTime - delta);
			if (timeLeft > MS_PER_DAY) {
				timeRemaining = Long.toString((timeLeft + MS_PER_DAY - 1)
						/ MS_PER_DAY)
						+ " "
						+ getString(R.string.download_activity_time_remaining_days);
			} else if (timeLeft > MS_PER_HOUR) {
				timeRemaining = Long.toString((timeLeft + MS_PER_HOUR - 1)
						/ MS_PER_HOUR)
						+ " "
						+ getString(R.string.download_activity_time_remaining_hours);
			} else if (timeLeft > MS_PER_MINUTE) {
				timeRemaining = Long.toString((timeLeft + MS_PER_MINUTE - 1)
						/ MS_PER_MINUTE)
						+ " "
						+ getString(R.string.download_activity_time_remaining_minutes);
			} else {
				timeRemaining = Long.toString((timeLeft + MS_PER_SECOND - 1)
						/ MS_PER_SECOND)
						+ " "
						+ getString(R.string.download_activity_time_remaining_seconds);
			}
		}
		mTimeRemaining.setText(timeRemaining);
	}

	private static void quietClose(InputStream is) {
		try {
			if (is != null) {
				is.close();
			}
		} catch (IOException e) {
			// Don't care.
		}
	}

	private static void quietClose(OutputStream os) {
		try {
			if (os != null) {
				os.close();
			}
		} catch (IOException e) {
			// Don't care.
		}
	}

	private static class Config {
		long getSize() {
			long result = 0;
			for (File file : mFiles) {
				result += file.getSize();
			}
			return result;
		}

		static class File {
			public File(String src, String dest, String md5, long size) {
				if (src != null) {
					this.mParts.add(new Part(src, md5, size));
				}
				this.dest = dest;
			}

			static class Part {
				Part(String src, String md5, long size) {
					this.src = src;
					this.md5 = md5;
					this.size = size;
				}

				String src;
				String md5;
				long size;
			}

			ArrayList<Part> mParts = new ArrayList<Part>();
			String dest;

			long getSize() {
				long result = 0;
				for (Part part : mParts) {
					if (part.size > 0) {
						result += part.size;
					}
				}
				return result;
			}
		}

		String version;
		ArrayList<File> mFiles = new ArrayList<File>();
	}

	/**
	 * <config version=""> <file src="http:..." dest ="b.x" /> <file dest="b.x">
	 * <part src="http:..." /> ... ... </config>
	 * 
	 */
	private static class ConfigHandler extends DefaultHandler {

		public static Config parse(InputStream is) throws SAXException,
				UnsupportedEncodingException, IOException {
			ConfigHandler handler = new ConfigHandler();
			Xml.parse(is, Xml.findEncodingByName("UTF-8"), handler);
			return handler.mConfig;
		}

		private ConfigHandler() {
			mConfig = new Config();
		}

		@Override
		public void startElement(String uri, String localName, String qName,
				Attributes attributes) throws SAXException {
			if (localName.equals("config")) {
				mConfig.version = getRequiredString(attributes, "version");
			} else if (localName.equals("file")) {
				String src = attributes.getValue("", "src");
				String dest = getRequiredString(attributes, "dest");
				String md5 = attributes.getValue("", "md5");
				long size = getLong(attributes, "size", -1);
				mConfig.mFiles.add(new Config.File(src, dest, md5, size));
			} else if (localName.equals("part")) {
				String src = getRequiredString(attributes, "src");
				String md5 = attributes.getValue("", "md5");
				long size = getLong(attributes, "size", -1);
				int length = mConfig.mFiles.size();
				if (length > 0) {
					mConfig.mFiles.get(length - 1).mParts
							.add(new Config.File.Part(src, md5, size));
				}
			}
		}

		private static String getRequiredString(Attributes attributes,
				String localName) throws SAXException {
			String result = attributes.getValue("", localName);
			if (result == null) {
				throw new SAXException("Expected attribute " + localName);
			}
			return result;
		}

		private static long getLong(Attributes attributes, String localName,
				long defaultValue) {
			String value = attributes.getValue("", localName);
			if (value == null) {
				return defaultValue;
			} else {
				return Long.parseLong(value);
			}
		}

		public Config mConfig;
	}

	@SuppressWarnings("serial")
	private class DownloaderException extends Exception {
		public DownloaderException(String reason) {
			super(reason);
		}
	}

	private class Downloader implements Runnable {
		public void run() {
			Intent intent = getIntent();
			mFileConfigUrl = intent.getStringExtra(EXTRA_FILE_CONFIG_URL);
			mConfigVersion = intent.getStringExtra(EXTRA_CONFIG_VERSION);
			mDataPath = intent.getStringExtra(EXTRA_DATA_PATH);

			mDataDir = new File(mDataPath);

			try {
				// Download files.
				mHttpClient = new DefaultHttpClient();
				Config config = getConfig();
				filter(config);
				persistantDownload(config);
				verify(config);
				cleanup();
				reportSuccess();
			} catch (Exception e) {
				reportFailure(e.toString() + "\n" + Log.getStackTraceString(e));
			}
		}

		private void persistantDownload(Config config)
				throws ClientProtocolException, DownloaderException,
				IOException {
			while (true) {
				try {
					download(config);
					break;
				} catch (java.net.SocketException e) {
					if (mSuppressErrorMessages) {
						throw e;
					}
				} catch (java.net.SocketTimeoutException e) {
					if (mSuppressErrorMessages) {
						throw e;
					}
				}
				Log.i(LOG_TAG, "Network connectivity issue, retrying.");
			}
		}

		private void filter(Config config) throws IOException,
				DownloaderException {
			File filteredFile = new File(mDataDir, LOCAL_FILTERED_FILE);
			if (filteredFile.exists()) {
				return;
			}

			File localConfigFile = new File(mDataDir, LOCAL_CONFIG_FILE_TEMP);
			HashSet<String> keepSet = new HashSet<String>();
			keepSet.add(localConfigFile.getCanonicalPath());

			HashMap<String, Config.File> fileMap = new HashMap<String, Config.File>();
			for (Config.File file : config.mFiles) {
				String canonicalPath = new File(mDataDir, file.dest)
						.getCanonicalPath();
				fileMap.put(canonicalPath, file);
			}
			recursiveFilter(mDataDir, fileMap, keepSet, false);
			touch(filteredFile);
		}

		private void touch(File file) throws FileNotFoundException {
			FileOutputStream os = new FileOutputStream(file);
			quietClose(os);
		}

		private boolean recursiveFilter(File base,
				HashMap<String, Config.File> fileMap, HashSet<String> keepSet,
				boolean filterBase) throws IOException, DownloaderException {
			boolean result = true;
			if (base.isDirectory()) {
				for (File child : base.listFiles()) {
					result &= recursiveFilter(child, fileMap, keepSet, true);
				}
			}
			if (filterBase) {
				if (base.isDirectory()) {
					if (base.listFiles().length == 0) {
						result &= base.delete();
					}
				} else {
					if (!shouldKeepFile(base, fileMap, keepSet)) {
						result &= base.delete();
					}
				}
			}
			return result;
		}

		private boolean shouldKeepFile(File file,
				HashMap<String, Config.File> fileMap, HashSet<String> keepSet)
				throws IOException, DownloaderException {
			String canonicalPath = file.getCanonicalPath();
			if (keepSet.contains(canonicalPath)) {
				return true;
			}
			Config.File configFile = fileMap.get(canonicalPath);
			if (configFile == null) {
				return false;
			}
			return verifyFile(configFile, false);
		}

		private void reportSuccess() {
			mHandler.sendMessage(Message.obtain(mHandler,
					MSG_DOWNLOAD_SUCCEEDED));
		}

		private void reportFailure(String reason) {
			mHandler.sendMessage(Message.obtain(mHandler, MSG_DOWNLOAD_FAILED,
					reason));
		}

		private void reportProgress(int progress) {
			mHandler.sendMessage(Message.obtain(mHandler, MSG_REPORT_PROGRESS,
					progress, 0));
		}

		private Config getConfig() throws DownloaderException,
				ClientProtocolException, IOException, SAXException {
			Config config = null;
			if (mDataDir.exists()) {
				config = getLocalConfig(mDataDir, LOCAL_CONFIG_FILE_TEMP);
				if ((config == null) || !mConfigVersion.equals(config.version)) {
					if (config == null) {
						Log.i(LOG_TAG, "Couldn't find local config.");
					} else {
						Log.i(LOG_TAG, "Local version out of sync. Wanted "
								+ mConfigVersion + " but have "
								+ config.version);
					}
					config = null;
				}
			} else {
				Log.i(LOG_TAG, "Creating directory " + mDataPath);
				mDataDir.mkdirs();
				mDataDir.mkdir();
				if (!mDataDir.exists()) {
					throw new DownloaderException(
							"Could not create the directory " + mDataPath);
				}
			}
			if (config == null) {
				File localConfig = download(mFileConfigUrl,
						LOCAL_CONFIG_FILE_TEMP);
				InputStream is = new FileInputStream(localConfig);
				try {
					config = ConfigHandler.parse(is);
				} finally {
					quietClose(is);
				}
				if (!config.version.equals(mConfigVersion)) {
					throw new DownloaderException(
							"Configuration file version mismatch. Expected "
									+ mConfigVersion + " received "
									+ config.version);
				}
			}
			return config;
		}

		private void noisyDelete(File file) throws IOException {
			if (!file.delete()) {
				throw new IOException("could not delete " + file);
			}
		}

		private void download(Config config) throws DownloaderException,
				ClientProtocolException, IOException {
			mDownloadedSize = 0;
			getSizes(config);
			Log.i(LOG_TAG, "Total bytes to download: " + mTotalExpectedSize);
			for (Config.File file : config.mFiles) {
				downloadFile(file);
			}
		}

		private void downloadFile(Config.File file) throws DownloaderException,
				FileNotFoundException, IOException, ClientProtocolException {
			boolean append = false;
			File dest = new File(mDataDir, file.dest);
			long bytesToSkip = 0;
			if (dest.exists() && dest.isFile()) {
				append = true;
				bytesToSkip = dest.length();
				mDownloadedSize += bytesToSkip;
			}
			FileOutputStream os = null;
			long offsetOfCurrentPart = 0;
			try {
				for (Config.File.Part part : file.mParts) {
					// The part.size==0 check below allows us to download
					// zero-length files.
					if ((part.size > bytesToSkip) || (part.size == 0)) {
						MessageDigest digest = null;
						if (part.md5 != null) {
							digest = createDigest();
							if (bytesToSkip > 0) {
								FileInputStream is = openInput(file.dest);
								try {
									is.skip(offsetOfCurrentPart);
									readIntoDigest(is, bytesToSkip, digest);
								} finally {
									quietClose(is);
								}
							}
						}
						if (os == null) {
							os = openOutput(file.dest, append);
						}
						downloadPart(part.src, os, bytesToSkip, part.size,
								digest);
						if (digest != null) {
							String hash = getHash(digest);
							if (!hash.equalsIgnoreCase(part.md5)) {
								Log.e(LOG_TAG,
										"web MD5 checksums don't match. "
												+ part.src + "\nExpected "
												+ part.md5 + "\n     got "
												+ hash);
								quietClose(os);
								dest.delete();
								throw new DownloaderException(
										"Received bad data from web server");
							} else {
								Log.i(LOG_TAG, "web MD5 checksum matches.");
							}
						}
					}
					bytesToSkip -= Math.min(bytesToSkip, part.size);
					offsetOfCurrentPart += part.size;
				}
			} finally {
				quietClose(os);
			}
		}

		private void cleanup() throws IOException {
			File filtered = new File(mDataDir, LOCAL_FILTERED_FILE);
			noisyDelete(filtered);
			File tempConfig = new File(mDataDir, LOCAL_CONFIG_FILE_TEMP);
			File realConfig = new File(mDataDir, LOCAL_CONFIG_FILE);
			tempConfig.renameTo(realConfig);
		}

		private void verify(Config config) throws DownloaderException,
				ClientProtocolException, IOException {
			Log.i(LOG_TAG, "Verifying...");
			String failFiles = null;
			for (Config.File file : config.mFiles) {
				if (!verifyFile(file, true)) {
					if (failFiles == null) {
						failFiles = file.dest;
					} else {
						failFiles += " " + file.dest;
					}
				}
			}
			if (failFiles != null) {
				throw new DownloaderException(
						"Possible bad SD-Card. MD5 sum incorrect for file(s) "
								+ failFiles);
			}
		}

		private boolean verifyFile(Config.File file, boolean deleteInvalid)
				throws FileNotFoundException, DownloaderException, IOException {
			Log.i(LOG_TAG, "verifying " + file.dest);
			File dest = new File(mDataDir, file.dest);
			if (!dest.exists()) {
				Log.e(LOG_TAG, "File does not exist: " + dest.toString());
				return false;
			}
			long fileSize = file.getSize();
			long destLength = dest.length();
			if (fileSize != destLength) {
				Log.e(LOG_TAG, "Length doesn't match. Expected " + fileSize
						+ " got " + destLength);
				if (deleteInvalid) {
					dest.delete();
					return false;
				}
			}
			FileInputStream is = new FileInputStream(dest);
			try {
				for (Config.File.Part part : file.mParts) {
					if (part.md5 == null) {
						continue;
					}
					MessageDigest digest = createDigest();
					readIntoDigest(is, part.size, digest);
					String hash = getHash(digest);
					if (!hash.equalsIgnoreCase(part.md5)) {
						Log.e(LOG_TAG, "MD5 checksums don't match. " + part.src
								+ " Expected " + part.md5 + " got " + hash);
						if (deleteInvalid) {
							quietClose(is);
							dest.delete();
						}
						return false;
					}
				}
			} finally {
				quietClose(is);
			}
			return true;
		}

		private void readIntoDigest(FileInputStream is, long bytesToRead,
				MessageDigest digest) throws IOException {
			while (bytesToRead > 0) {
				int chunkSize = (int) Math.min(mFileIOBuffer.length,
						bytesToRead);
				int bytesRead = is.read(mFileIOBuffer, 0, chunkSize);
				if (bytesRead < 0) {
					break;
				}
				updateDigest(digest, bytesRead);
				bytesToRead -= bytesRead;
			}
		}

		private MessageDigest createDigest() throws DownloaderException {
			MessageDigest digest;
			try {
				digest = MessageDigest.getInstance("MD5");
			} catch (NoSuchAlgorithmException e) {
				throw new DownloaderException("Couldn't create MD5 digest");
			}
			return digest;
		}

		private void updateDigest(MessageDigest digest, int bytesRead) {
			if (bytesRead == mFileIOBuffer.length) {
				digest.update(mFileIOBuffer);
			} else {
				// Work around an awkward API: Create a
				// new buffer with just the valid bytes
				byte[] temp = new byte[bytesRead];
				System.arraycopy(mFileIOBuffer, 0, temp, 0, bytesRead);
				digest.update(temp);
			}
		}

		private String getHash(MessageDigest digest) {
			StringBuilder builder = new StringBuilder();
			for (byte b : digest.digest()) {
				builder.append(Integer.toHexString((b >> 4) & 0xf));
				builder.append(Integer.toHexString(b & 0xf));
			}
			return builder.toString();
		}

		/**
		 * Ensure we have sizes for all the items.
		 * 
		 * @param config
		 * @throws ClientProtocolException
		 * @throws IOException
		 * @throws DownloaderException
		 */
		private void getSizes(Config config) throws ClientProtocolException,
				IOException, DownloaderException {
			for (Config.File file : config.mFiles) {
				for (Config.File.Part part : file.mParts) {
					if (part.size < 0) {
						part.size = getSize(part.src);
					}
				}
			}
			mTotalExpectedSize = config.getSize();
		}

		private long getSize(String url) throws ClientProtocolException,
				IOException {
			url = normalizeUrl(url);
			Log.i(LOG_TAG, "Head " + url);
			HttpHead httpGet = new HttpHead(url);
			HttpResponse response = mHttpClient.execute(httpGet);
			if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
				throw new IOException("Unexpected Http status code "
						+ response.getStatusLine().getStatusCode());
			}
			Header[] clHeaders = response.getHeaders("Content-Length");
			if (clHeaders.length > 0) {
				Header header = clHeaders[0];
				return Long.parseLong(header.getValue());
			}
			return -1;
		}

		private String normalizeUrl(String url) throws MalformedURLException {
			return (new URL(new URL(mFileConfigUrl), url)).toString();
		}

		private InputStream get(String url, long startOffset,
				long expectedLength) throws ClientProtocolException,
				IOException {
			url = normalizeUrl(url);
			Log.i(LOG_TAG, "Get " + url);

			mHttpGet = new HttpGet(url);
			int expectedStatusCode = HttpStatus.SC_OK;
			if (startOffset > 0) {
				String range = "bytes=" + startOffset + "-";
				if (expectedLength >= 0) {
					range += expectedLength - 1;
				}
				Log.i(LOG_TAG, "requesting byte range " + range);
				mHttpGet.addHeader("Range", range);
				expectedStatusCode = HttpStatus.SC_PARTIAL_CONTENT;
			}
			HttpResponse response = mHttpClient.execute(mHttpGet);
			long bytesToSkip = 0;
			int statusCode = response.getStatusLine().getStatusCode();
			if (statusCode != expectedStatusCode) {
				if ((statusCode == HttpStatus.SC_OK)
						&& (expectedStatusCode == HttpStatus.SC_PARTIAL_CONTENT)) {
					Log.i(LOG_TAG, "Byte range request ignored");
					bytesToSkip = startOffset;
				} else {
					throw new IOException("Unexpected Http status code "
							+ statusCode + " expected " + expectedStatusCode);
				}
			}
			HttpEntity entity = response.getEntity();
			InputStream is = entity.getContent();
			if (bytesToSkip > 0) {
				is.skip(bytesToSkip);
			}
			return is;
		}

		private File download(String src, String dest)
				throws DownloaderException, ClientProtocolException,
				IOException {
			File destFile = new File(mDataDir, dest);
			FileOutputStream os = openOutput(dest, false);
			try {
				downloadPart(src, os, 0, -1, null);
			} finally {
				os.close();
			}
			return destFile;
		}

		private void downloadPart(String src, FileOutputStream os,
				long startOffset, long expectedLength, MessageDigest digest)
				throws ClientProtocolException, IOException,
				DownloaderException {
			boolean lengthIsKnown = expectedLength >= 0;
			if (startOffset < 0) {
				throw new IllegalArgumentException("Negative startOffset:"
						+ startOffset);
			}
			if (lengthIsKnown && (startOffset > expectedLength)) {
				throw new IllegalArgumentException(
						"startOffset > expectedLength" + startOffset + " "
								+ expectedLength);
			}
			InputStream is = get(src, startOffset, expectedLength);
			try {
				long bytesRead = downloadStream(is, os, digest);
				if (lengthIsKnown) {
					long expectedBytesRead = expectedLength - startOffset;
					if (expectedBytesRead != bytesRead) {
						Log.e(LOG_TAG, "Bad file transfer from server: " + src
								+ " Expected " + expectedBytesRead
								+ " Received " + bytesRead);
						throw new DownloaderException(
								"Incorrect number of bytes received from server");
					}
				}
			} finally {
				is.close();
				mHttpGet = null;
			}
		}

		private FileOutputStream openOutput(String dest, boolean append)
				throws FileNotFoundException, DownloaderException {
			File destFile = new File(mDataDir, dest);
			File parent = destFile.getParentFile();
			if (!parent.exists()) {
				parent.mkdirs();
			}
			if (!parent.exists()) {
				throw new DownloaderException("Could not create directory "
						+ parent.toString());
			}
			FileOutputStream os = new FileOutputStream(destFile, append);
			return os;
		}

		private FileInputStream openInput(String src)
				throws FileNotFoundException, DownloaderException {
			File srcFile = new File(mDataDir, src);
			File parent = srcFile.getParentFile();
			if (!parent.exists()) {
				parent.mkdirs();
			}
			if (!parent.exists()) {
				throw new DownloaderException("Could not create directory "
						+ parent.toString());
			}
			return new FileInputStream(srcFile);
		}

		private long downloadStream(InputStream is, FileOutputStream os,
				MessageDigest digest) throws DownloaderException, IOException {
			long totalBytesRead = 0;
			while (true) {
				if (Thread.interrupted()) {
					Log.i(LOG_TAG, "downloader thread interrupted.");
					mHttpGet.abort();
					throw new DownloaderException("Thread interrupted");
				}
				int bytesRead = is.read(mFileIOBuffer);
				if (bytesRead < 0) {
					break;
				}
				if (digest != null) {
					updateDigest(digest, bytesRead);
				}
				totalBytesRead += bytesRead;
				os.write(mFileIOBuffer, 0, bytesRead);
				mDownloadedSize += bytesRead;
				int progress = (int) (Math.min(mTotalExpectedSize,
						mDownloadedSize * 10000
								/ Math.max(1, mTotalExpectedSize)));
				if (progress != mReportedProgress) {
					mReportedProgress = progress;
					reportProgress(progress);
				}
			}
			return totalBytesRead;
		}

		private HttpClient mHttpClient;
		private HttpGet mHttpGet;
		private String mFileConfigUrl;
		private String mConfigVersion;
		private String mDataPath;
		private File mDataDir;
		private long mTotalExpectedSize;
		private long mDownloadedSize;
		private int mReportedProgress;
		private final static int CHUNK_SIZE = 32 * 1024;
		byte[] mFileIOBuffer = new byte[CHUNK_SIZE];
	}

	private final static String LOG_TAG = "Downloader";
	private TextView mProgress;
	private TextView mTimeRemaining;
	private final DecimalFormat mPercentFormat = new DecimalFormat("0.00 %");
	private long mStartTime;
	private Thread mDownloadThread;
	private boolean mSuppressErrorMessages;

	private final static long MS_PER_SECOND = 1000;
	private final static long MS_PER_MINUTE = 60 * 1000;
	private final static long MS_PER_HOUR = 60 * 60 * 1000;
	private final static long MS_PER_DAY = 24 * 60 * 60 * 1000;

	private final static String LOCAL_CONFIG_FILE = ".downloadConfig";
	private final static String LOCAL_CONFIG_FILE_TEMP = ".downloadConfig_temp";
	private final static String LOCAL_FILTERED_FILE = ".downloadConfig_filtered";
	private final static String EXTRA_CUSTOM_TEXT = "DownloaderActivity_custom_text";
	private final static String EXTRA_FILE_CONFIG_URL = "DownloaderActivity_config_url";
	private final static String EXTRA_CONFIG_VERSION = "DownloaderActivity_config_version";
	private final static String EXTRA_DATA_PATH = "DownloaderActivity_data_path";
	private final static String EXTRA_USER_AGENT = "DownloaderActivity_user_agent";

	private final static int MSG_DOWNLOAD_SUCCEEDED = 0;
	private final static int MSG_DOWNLOAD_FAILED = 1;
	private final static int MSG_REPORT_PROGRESS = 2;

	private final Handler mHandler = new Handler() {
		@Override
		public void handleMessage(Message msg) {
			switch (msg.what) {
			case MSG_DOWNLOAD_SUCCEEDED:
				onDownloadSucceeded();
				break;
			case MSG_DOWNLOAD_FAILED:
				onDownloadFailed((String) msg.obj);
				break;
			case MSG_REPORT_PROGRESS:
				onReportProgress(msg.arg1);
				break;
			default:
				throw new IllegalArgumentException("Unknown message id "
						+ msg.what);
			}
		}

	};

}
